require "./authd.cr"
require "sodium"

extend AuthD

require "./configuration"

class Array(T)
	def contains?(value : T)
		(self.select { |x| x == value }).size > 0
	end
end

# WIP: select (dynamically) messages to mask

module AuthD
	enum MESSAGE
		KEEPALIVE
		LOGIN
		# TODO
	end
end

alias IPCMESSAGE = Baguette::Configuration::IPC::MESSAGE
alias AUTHMESSAGE = AuthD::MESSAGE

# Provides a JWT-based authentication scheme for service-specific users.
class AuthD::Service < IPC
	property configuration   : Baguette::Configuration::Auth

	# DB and its indexes.
	property users           : DODB::Storage::Common(User)
	property users_per_uid   : DODB::Trigger::IndexCached(User)
	property users_per_login : DODB::Trigger::IndexCached(User)
	property users_per_email : DODB::Trigger::IndexCached(User)

	property logged_users    : Hash(Int32, AuthD::User::Public)

	# #{@configuration.storage_directory}/last_used_uid
	property last_uid_file   : String

	def initialize(@configuration)
		super()

		@users = DODB::Storage::Common(User).new @configuration.storage_directory, 5000
		@users_per_uid   = @users.new_index "uid",   &.uid.to_s
		@users_per_login = @users.new_index "login", &.login
		@users_per_email = @users.new_index "email" do |user|
			if mail = user.contact.email
				Base64.encode(mail).chomp
			else
				DODB.no_index
			end
		end

		@last_uid_file = "#{@configuration.storage_directory}/last_used_uid"

		@logged_users = Hash(Int32, AuthD::User::Public).new

		if @configuration.recreate_indexes
			Baguette::Log.info "Recreate indexes"
			@users.reindex_everything!
		end

		self.timer @configuration.ipc_timer
		self.service_init @configuration.service_name
	end

	def should_display?(value : AUTHMESSAGE)
		(@configuration.messages_to_mask.select { |x| x == value }).size == 0
	end

	def should_display?(value : IPCMESSAGE)
		@configuration.ipc_messages_to_show.contains? value
	end

	def obsolete_hash_password(password : String) : String
		digest = OpenSSL::Digest.new "sha256"
		digest << password
		digest.hexfinal
	end

	def hash_password(password : String) : String
		pwhash = Sodium::Password::Hash.new

		hash = pwhash.create password
		pwhash.verify hash, password

		Base64.strict_encode hash
	end

	# new_uid reads the last given UID and returns it incremented.
	# Splitting the retrieval and record of new user ids allows to
	# only increment when an user fully registers, thus avoiding a
	# Denial of Service attack.
	#
	# WARNING: to record this new UID, new_uid_commit must be called.
	# WARNING: new_uid isn't thread safe.
	def new_uid : UInt32
		uid : UInt32 = begin
			File.read(@last_uid_file).to_u32
		rescue
			999.to_u32
		end
		uid += 1
	end

	# new_uid_commit records the new UID.
	# WARNING: new_uid_commit isn't thread safe.
	def new_uid_commit(uid : Int)
		File.write @last_uid_file, uid.to_s
	end

	def get_logged_user?(fd : Int32) : AuthD::User::Public?
		@logged_users[fd]?
	end

	# Instead of just getting the public view of a logged user,
	# get the actual User instance.
	def get_logged_user_full?(fd : Int32)
		if u = @logged_users[fd]?
			user? u.uid
		end
	end

	# `log_user_info` provides a string composed from either the user
	# id in case the user was authenticated or the file descriptor of
	# the connection.
	def log_user_info(fd : Int32) : String
		if user = get_logged_user? fd
			"userid #{user.uid}"
		else
			"fd #{"%4d" % fd}"
		end
	end

	def user?(uid_or_login : UserID)
		if uid_or_login.is_a? UInt32
			@users_per_uid.get? uid_or_login.to_s
		else
			@users_per_login.get? uid_or_login
		end
	end

	def handle_request(event : IPC::Event)
		request_start = Time.utc

		array = event.message.not_nil!
		slice = Slice.new array.to_unsafe, array.size
		message = IPCMessage::TypedMessage.deserialize slice
		request = AuthD.requests.parse_ipc_json message.not_nil!

		if request.nil?
			raise "unknown request type"
		end

		request_name = request.class.name.sub /^AuthD::Request::/, ""
		connection_info_str = log_user_info event.fd

		response = begin
			request.handle self, event.fd
		rescue e : UserNotFound
			Baguette::Log.error "(#{connection_info_str}) #{request} user not found"
			AuthD::Response::Error.new "authorization error"
		rescue e : AuthenticationInfoLacking
			Baguette::Log.error "(#{connection_info_str}) #{request} lacking authentication info"
			AuthD::Response::Error.new "authorization error"
		rescue e : AdminAuthorizationException
			Baguette::Log.error "(#{connection_info_str}) #{request} admin authentication failed"
			AuthD::Response::Error.new "authorization error"
		rescue e
			Baguette::Log.error "(#{connection_info_str}) #{request} generic error #{e}"
			AuthD::Response::Error.new "unknown error"
		end

		# If clients sent requests with an “id” field, it is copied
		# in the responses. Allows identifying responses easily.
		response.id = request.id

		schedule event.fd, response

		duration = Time.utc - request_start

		if response.is_a? AuthD::Response::Error
			Baguette::Log.warning "(#{connection_info_str}) (#{duration}) #{request} >> #{response}"
		else
			if request_name != "KeepAlive" || should_display? AUTHMESSAGE::KEEPALIVE
				Baguette::Log.debug "(#{connection_info_str}) (#{duration}) #{request} >> #{response}"
			end
		end
	end

	def get_user_from_token(token : String)
		token_payload = Token.from_s(@configuration.secret_key, token)

		@users_per_uid.get? token_payload.uid.to_s
	end

	def run
		Baguette::Log.title "Starting #{@configuration.service_name}"

		Baguette::Log.info "(mailer) Email activation template: #{@configuration.activation_template}"
		Baguette::Log.info "(mailer) Email recovery template: #{@configuration.recovery_template}"
		if skf = @configuration.secret_key_file
			Baguette::Log.info "secret key file is: #{skf}"
		else
			Baguette::Log.error "no secret key file"
			exit 1
		end

		self.loop do |event|
			case event.type
			when LibIPC::EventType::Timer
				Baguette::Log.debug "Timer" if should_display? IPCMESSAGE::TIMER

			when LibIPC::EventType::MessageRx
				Baguette::Log.debug "Received message from #{event.fd}" if should_display? IPCMESSAGE::RX
				begin
					handle_request event
				rescue e
					Baguette::Log.error "#{e.message}"
					# send event.fd, Response::Error.new e.message
				end

			when LibIPC::EventType::MessageTx
				Baguette::Log.debug "Message sent to #{event.fd}" if should_display? IPCMESSAGE::TX

			when LibIPC::EventType::Connection
				Baguette::Log.debug "Connection from #{event.fd}" if should_display? IPCMESSAGE::CONNECTION
			when LibIPC::EventType::Disconnection
				Baguette::Log.debug "Disconnection from #{event.fd}" if should_display? IPCMESSAGE::DISCONNECTION
				@logged_users.delete event.fd
			else
				Baguette::Log.error "Not implemented behavior for event: #{event}"
				if event.responds_to?(:fd)
					fd = event.fd
					Baguette::Log.warning "closing #{fd}"
					close fd
					@logged_users.delete fd
				end
			end
		end

	end
end
